Skip to content

feat: 어드민 코스 플랜 구성항목 UX 개선#686

Merged
Hyeonjun0527 merged 1 commit into
developfrom
feat/admin-course-plan-items
May 23, 2026
Merged

feat: 어드민 코스 플랜 구성항목 UX 개선#686
Hyeonjun0527 merged 1 commit into
developfrom
feat/admin-course-plan-items

Conversation

@Hyeonjun0527

@Hyeonjun0527 Hyeonjun0527 commented May 23, 2026

Copy link
Copy Markdown
Member

Summary

  • 구성 항목 입력을 자유 텍스트 textarea에서 행 단위 편집 UI로 변경했습니다.
  • 플랜 저장 전 가격/할인가/구성 항목/대표 플랜 중복 검증을 추가했습니다.
  • 코스 기본정보 폼의 운영자 혼동 문구(자동 계산 항목, API 요청 본문 미포함 안내)를 제거했습니다.

API

  • 새 API 없음
  • 기존 AdminCoursePlanUpsertRequest.items[] 요청 shape 유지
  • 백엔드 AdminCourseContentControllerGET/POST/PUT/PATCH /api/v5/admin/courses/{courseId}/plans* 계약과 대조 확인

Validation

  • yarn prettier:fix (성공, 기존 .codex/skills/clarify.md symlink warning 출력)
  • yarn lint:fix (성공, 기존 repo warnings만 출력)
  • yarn typecheck (성공)
  • git diff --check (성공)

Summary by CodeRabbit

변경 사항

  • 새로운 기능

    • 코스 기간(일) 입력 필드 추가
    • 플랜 항목 편집 UI 개선으로 직관적인 항목 관리 지원 (코드, 이름, 금액, 순서 설정 및 추가/삭제)
  • 개선 사항

    • 얼리버드 할인가가 정가를 초과하지 않도록 유효성 검사 추가
    • 코스당 대표 플랜을 1개로 제한하는 검증 추가
    • 숫자 입력 검증 강화

Review Change Stack

@vercel

vercel Bot commented May 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
study-platform-client-dev Ready Ready Preview, Comment May 23, 2026 1:56am

@coderabbitai

coderabbitai Bot commented May 23, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

코스 관리 폼에서 "가격/얼리버드" 입력 필드를 제거하고 "기간(일)" 필드를 추가합니다. 동시에 플랜 항목 관리를 문자열 기반 직렬화에서 구조화된 배열 기반으로 전환하고, 숫자 입력 검증을 강화하며, 대표 플랜 중복을 방지합니다.

Changes

코스 플랜 관리 구조화

Layer / File(s) Summary
코스 형식 필드 정리
src/components/admin/courses/admin-course-form-sections.tsx
폼 그리드에서 "가격/얼리버드" 입력 섹션을 제거하고 그 자리에 "기간(일)" 정수 입력 필드(기본값 가이드 포함)를 추가합니다. 자동 계산 항목 안내 정보 박스도 삭제됩니다.
플랜 항목 타입 및 유틸리티
src/components/admin/courses/admin-course-plan-management.tsx
PlanItemFormValue 타입(코드/라벨/금액/표시 순서)과 emptyPlanItemForm 기본값을 정의합니다. isDigitsOnly 숫자 문자열 검증 헬퍼 및 Plus/Trash2 아이콘을 import 합니다.
폼 값 변환 로직 재구성
src/components/admin/courses/admin-course-plan-management.tsx
toPlanFormValues를 구조화된 항목 배열로 매핑하고, 양수/0 이상 수 파서(parseRequiredPositiveNumber, parseRequiredNonNegativeNumber, parseOptionalNonNegativeNumber)와 parsePlanItems를 추가합니다. toPlanPayload에서 정가 및 할인가를 검증하고(할인가 ≤ 정가), 항목을 변환해 payload에 포함합니다.
대표 플랜 충돌 검증
src/components/admin/courses/admin-course-plan-management.tsx
hasRecommendedPlanConflict 헬퍼로 코스당 최대 1개 대표 플랜만 허용합니다. 플랜 생성(handleCreatePlan) 및 수정(handleUpdatePlan) 시 충돌 감지 시 토스트를 띄우고 요청을 중단합니다.
플랜 항목 에디터 UI
src/components/admin/courses/admin-course-plan-management.tsx
구성 항목 입력을 단일 <textarea>에서 PlanItemsEditor 컴포넌트로 교체합니다. 항목별 코드/라벨/금액/표시 순서를 개별 입력 필드로 제공하고, 항목 추가/삭제 버튼을 구현합니다. 비활성화 섹션에 안내 문구 및 레이아웃 래핑을 추가합니다.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes


Possibly related PRs


🐰 항목 구조를 정렬하네요,
텍스트에서 배열로 춤을 춥니다,
검증은 더 강해지고,
UI는 더 명확해지니,
플랜 관리가 반짝반짝! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목은 '어드민 코스 플랜 구성항목 UX 개선'으로, 변경사항의 핵심인 구성항목 입력 UI 개선과 관련 검증 로직 강화를 명확하게 요약합니다.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/admin-course-plan-items

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/components/admin/courses/admin-course-plan-management.tsx (1)

252-255: ⚡ Quick win

컴포넌트 내부 토스트 호출은 훅으로 꺼내서 재사용해주세요.

handleCreatePlan/handleUpdatePlan 안에서 useToastStore.getState()를 직접 호출하면 이 파일만 예외적인 패턴이 됩니다. 컴포넌트 상단에서 const showToast = useToastStore((state) => state.showToast);를 가져와 재사용하는 쪽이 가이드와도 맞습니다. As per coding guidelines, "Use useToastStore to show toast notifications. Call useToastStore((state) => state.showToast) inside React, or useToastStore.getState().showToast() outside React (e.g., axios interceptors, mutation callbacks)".

Also applies to: 279-282

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/admin/courses/admin-course-plan-management.tsx` around lines
252 - 255, The toast calls inside handleCreatePlan and handleUpdatePlan use
useToastStore.getState() directly; instead, at the top of the component pull the
hook into a reusable function reference (e.g., const showToast =
useToastStore(state => state.showToast);) and replace
useToastStore.getState().showToast(...) invocations (including the call guarded
by hasRecommendedPlanConflict(createForm) and the similar block around lines
~279-282) with showToast(...) so the component follows the guideline of using
the hook inside React.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/admin/courses/admin-course-plan-management.tsx`:
- Around line 234-245: The conflict check in hasRecommendedPlanConflict
incorrectly reads other plans' unsaved local states from editForms (via
toPlanFormValues) causing false negatives; update hasRecommendedPlanConflict to
only consider the persisted value on each plan (plansQuery.data) when
determining if another plan is already recommended — i.e., keep the early return
when targetForm.isRecommended !== 'true', then iterate plansQuery.data and
ignore the plan with plan.planId === targetPlanId but for all others compare
plan.isRecommended (the saved property) directly instead of using editForms or
toPlanFormValues.
- Around line 557-560: The rendered list in PlanItemsEditor uses unstable
key={index}, causing input state reuse when removeItem/addItem reorder elements;
add a unique editor-only id field to PlanItemFormValue (e.g., id: string) when
items are created/loaded and update any addItem/removeItem logic to preserve or
generate that id, then change the mapping to use key={item.id ?? index} (or
require id) instead of key={index} so each row has a stable React key; update
any constructors/parsers that create PlanItemFormValue to assign a UUID or
unique string to item.id.

---

Nitpick comments:
In `@src/components/admin/courses/admin-course-plan-management.tsx`:
- Around line 252-255: The toast calls inside handleCreatePlan and
handleUpdatePlan use useToastStore.getState() directly; instead, at the top of
the component pull the hook into a reusable function reference (e.g., const
showToast = useToastStore(state => state.showToast);) and replace
useToastStore.getState().showToast(...) invocations (including the call guarded
by hasRecommendedPlanConflict(createForm) and the similar block around lines
~279-282) with showToast(...) so the component follows the guideline of using
the hook inside React.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4b57cef1-21f1-4ba4-9562-df01515ae08c

📥 Commits

Reviewing files that changed from the base of the PR and between c8277ba and 03c4392.

📒 Files selected for processing (2)
  • src/components/admin/courses/admin-course-form-sections.tsx
  • src/components/admin/courses/admin-course-plan-management.tsx
💤 Files with no reviewable changes (1)
  • src/components/admin/courses/admin-course-form-sections.tsx

Comment on lines +234 to +245
const hasRecommendedPlanConflict = (
targetForm: PlanFormValues,
targetPlanId?: number,
) => {
if (targetForm.isRecommended !== 'true') return false;

return (
plansQuery.data?.some((plan) => {
if (plan.planId === targetPlanId) return false;
const planForm = editForms[plan.planId] ?? toPlanFormValues(plan);
return planForm.isRecommended === 'true';
}) ?? false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

대표 플랜 충돌 검사가 저장되지 않은 다른 폼 상태까지 반영됩니다.

다른 플랜의 editForms 값을 그대로 읽어서, 아직 저장되지 않은 "대표 해제/설정"이 충돌 판단에 섞입니다. 예를 들어 기존 대표 플랜 A를 폼에서만 일반으로 바꿔둔 뒤 저장하지 않고 플랜 B를 대표로 저장하면 이 검사를 통과해 서버에는 대표 플랜이 2개 남을 수 있습니다. 여기서는 다른 플랜의 로컬 편집값이 아니라, 실제 저장된 plan.isRecommended만 기준으로 막아야 합니다.

🐛 제안 수정안
 const hasRecommendedPlanConflict = (
   targetForm: PlanFormValues,
   targetPlanId?: number,
 ) => {
   if (targetForm.isRecommended !== 'true') return false;

   return (
     plansQuery.data?.some((plan) => {
       if (plan.planId === targetPlanId) return false;
-      const planForm = editForms[plan.planId] ?? toPlanFormValues(plan);
-      return planForm.isRecommended === 'true';
+      return plan.isRecommended;
     }) ?? false
   );
 };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/admin/courses/admin-course-plan-management.tsx` around lines
234 - 245, The conflict check in hasRecommendedPlanConflict incorrectly reads
other plans' unsaved local states from editForms (via toPlanFormValues) causing
false negatives; update hasRecommendedPlanConflict to only consider the
persisted value on each plan (plansQuery.data) when determining if another plan
is already recommended — i.e., keep the early return when
targetForm.isRecommended !== 'true', then iterate plansQuery.data and ignore the
plan with plan.planId === targetPlanId but for all others compare
plan.isRecommended (the saved property) directly instead of using editForms or
toPlanFormValues.

Comment on lines +557 to +560
items.map((item, index) => (
<div
key={index}
className="border-border-subtle grid grid-cols-12 items-start gap-75 border-t px-100 py-75"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Inspect the exact map block around the reported lines
FILE="src/components/admin/courses/admin-course-plan-management.tsx"
echo "=== Context around ${FILE}:557-590 ==="
nl -ba "$FILE" | sed -n '540,590p'

# 2) Find the type/definition for PlanItemFormValue (or nearby form value type) and inspect its shape
echo
echo "=== Search for PlanItemFormValue in ${FILE} ==="
rg -n "PlanItemFormValue" "$FILE" || true

# 3) If there is a local type in this file, print the relevant region
echo
echo "=== Show nearby type definitions involving PlanItemFormValue ==="
# Grab a broader window around the first match if exists
MATCH_LINE=$(rg -n "type PlanItemFormValue|interface PlanItemFormValue|PlanItemFormValue" "$FILE" | head -n 1 | cut -d: -f1 || true)
if [[ -n "${MATCH_LINE:-}" ]]; then
  START=$((MATCH_LINE-60))
  END=$((MATCH_LINE+120))
  nl -ba "$FILE" | sed -n "${START},${END}p"
else
  echo "No PlanItemFormValue match found to expand."
fi

# 4) Locate items source/type for this items.map block (search for 'items' declaration near the map)
echo
echo "=== Search for declaration/assignment of items near the map block ==="
nl -ba "$FILE" | sed -n '500,560p'
rg -n "const items|let items|items =" "$FILE" | head -n 20 || true

# 5) Print the immediate block above items.map to see what 'items' contains
echo
echo "=== Print the code immediately above the map usage ==="
nl -ba "$FILE" | sed -n '520,565p'

Repository: code-zero-to-one/study-platform-client

Length of output: 236


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/components/admin/courses/admin-course-plan-management.tsx"

echo "=== Context around ${FILE}:540-610 (with line numbers via awk) ==="
awk -v start=540 -v end=610 'NR>=start && NR<=end {printf "%d\t%s\n", NR, $0}' "$FILE"

echo
echo "=== Search for PlanItemFormValue in ${FILE} ==="
rg -n "PlanItemFormValue" "$FILE" || true

echo
echo "=== Search for type/definition of items.map block variable `items` ==="
# Look for likely declarations near the first map usage
rg -n "items\.map\(" "$FILE" || true
rg -n "const items|let items|items\s*=" "$FILE" || true

echo
echo "=== Print the block right above items.map usage (if found) ==="
MAP_LINE=$(rg -n "items\.map\(" "$FILE" | head -n 1 | cut -d: -f1 || true)
if [[ -n "${MAP_LINE:-}" ]]; then
  START=$((MAP_LINE-70))
  END=$((MAP_LINE+40))
  echo "First items.map at line: $MAP_LINE"
  awk -v start=$START -v end=$END 'NR>=start && NR<=end {printf "%d\t%s\n", NR, $0}' "$FILE"
else
  echo "No items.map(...) match found."
fi

Repository: code-zero-to-one/study-platform-client

Length of output: 7252


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="src/components/admin/courses/admin-course-plan-management.tsx"

# Show around updateItem and the form state wiring
echo "=== Context around updateItem / parsePlanItems wiring (lines 430-540) ==="
awk -v start=430 -v end=560 'NR>=start && NR<=end {printf "%d\t%s\n", NR, $0}' "$FILE"

echo
echo "=== Search for row removal/add handlers near this map ==="
rg -n "remove|delete|splice|filter|add.*item|append|push" "$FILE" || true

echo
# If there is a remove function, print region around it
echo "=== Show contexts for likely handlers (first 5 matches) ==="
rg -n "remove|delete|splice|filter|push|append" "$FILE" | head -n 5 | cut -d: -f1 | while read -r L; do
  START=$((L-25)); END=$((L+25))
  echo "--- Around line $L ($START-$END) ---"
  awk -v start=$START -v end=$END 'NR>=start && NR<=end {printf "%d\t%s\n", NR, $0}' "$FILE"
  echo
done

Repository: code-zero-to-one/study-platform-client

Length of output: 11873


동적 행 목록에서 key={index}를 안정 키로 교체 필요

PlanItemsEditor에서 items.map((item, index) => ...)로 렌더링하며 key={index}를 사용합니다. 또한 removeItem/addItem로 배열 인덱스가 바뀌기 때문에 행의 입력 상태(포커스/IME/커서 등)가 다음 행으로 재사용될 위험이 있습니다. 현재 PlanItemFormValue에는 id 같은 고유 식별자가 없으니, 편집기 전용 idPlanItemFormValue에 추가하고 key={item.id ?? index}로 바꾸는 편이 안전합니다.

items.map((item, index) => (
  <div
    key={index}
    className="border-border-subtle grid grid-cols-12 items-start gap-75 border-t px-100 py-75"
  >
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/admin/courses/admin-course-plan-management.tsx` around lines
557 - 560, The rendered list in PlanItemsEditor uses unstable key={index},
causing input state reuse when removeItem/addItem reorder elements; add a unique
editor-only id field to PlanItemFormValue (e.g., id: string) when items are
created/loaded and update any addItem/removeItem logic to preserve or generate
that id, then change the mapping to use key={item.id ?? index} (or require id)
instead of key={index} so each row has a stable React key; update any
constructors/parsers that create PlanItemFormValue to assign a UUID or unique
string to item.id.

@Hyeonjun0527 Hyeonjun0527 merged commit 8b137ad into develop May 23, 2026
17 checks passed
@Hyeonjun0527 Hyeonjun0527 deleted the feat/admin-course-plan-items branch May 23, 2026 01:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant